/* 
 * Copyright 2009 Marc Englund <marc.englundATitmill.com>
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package org.vaadin.simplegesture.client.ui;

import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;

import com.google.gwt.dom.client.Document;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Event.NativePreviewEvent;
import com.google.gwt.user.client.Event.NativePreviewHandler;
import com.google.gwt.user.client.EventPreview;
import com.google.gwt.user.client.ui.RootPanel;
import com.google.gwt.user.client.ui.Widget;
import com.vaadin.terminal.gwt.client.ApplicationConnection;
import com.vaadin.terminal.gwt.client.BrowserInfo;
import com.vaadin.terminal.gwt.client.Paintable;
import com.vaadin.terminal.gwt.client.UIDL;

/**
 * Client side widget which communicates with the server. Messages from the
 * server are shown as HTML and mouse clicks are sent to the server.
 */
public class VSimpleGesture extends Widget implements Paintable {

    private ApplicationConnection client;
    private String pid;

    // Activate with this button
    // TODO make configurable
    private int mouseButton = Event.BUTTON_LEFT;

    private boolean recording = false; // record-mode

    private int maxDistance = Common.MAXDISTANCE; // Levensthein distance limit
    private boolean moving = false; // tracking a gesture
    // last recorded 'move' data:
    private int lastX = -1;
    private int lastY = -1;
    private long lastMoveTime = 0;

    private boolean normalize = false;
    private int lastMove = -1;

    private String moved = ""; // moves thus far
    private ArrayList gestures; // known gestures
    private ArrayList listeners; // gesture listeners

    // We're using event preview
    // TODO this should be changed, new GWT versions support much better ways
    private EventPreview evp = null;

    public VSimpleGesture() {
        /*
         * TODO Note for multitouch: Might want to order touches based on
         * startingpoint coordinates (upper-left first etc). Unclear: should it
         * match even if order is not correct?
         */

        RootPanel root = RootPanel.get();
        root.sinkEvents(Event.TOUCHEVENTS);
        /*-
        root.addHandler(new TouchStartHandler() {
            public void onTouchStart(TouchStartEvent event) {
                // TODO
                VConsole.error("TouchStartEvent");
            }
        }, TouchStartEvent.getType());
        -*/
        Event.addNativePreviewHandler(new NativePreviewHandler() {
            public void onPreviewNativeEvent(NativePreviewEvent event) {
                handleEvent(Event.as(event.getNativeEvent()));
            }
        });

        // TODO own event adding (jsni or wait for GWT 1.6?)
        /*-
        if (evp == null) {
            evp = new EventPreview() {
                public boolean onEventPreview(Event event) {
                    return handleEvent(event);
                }
            };
            DOM.addEventPreview(evp);
        }
        -*/

        // Add a hidden div that represents this component
        setElement(Document.get().createDivElement());
        if (BrowserInfo.get().isIE6()) {
            getElement().getStyle().setProperty("overflow", "hidden");
            getElement().getStyle().setProperty("height", "0");
        }

    }

    /**
     * Adds a GestureListener. When a gesture is matched, or record mode is on,
     * GestureListener.gestureEvent() will be called with the appropriate data.
     * 
     * @param listener
     *            the listener to add
     */
    public void addListener(GestureListener listener) {
        if (this.listeners == null) {
            this.listeners = new ArrayList();
        }
        this.listeners.add(listener);
    }

    /**
     * Remove a listener added with {@link #addListener(GestureListener)}.
     * 
     * @param listener
     *            the listener to remove
     */
    public void removeListener(GestureListener listener) {
        if (this.listeners != null) {
            this.listeners.remove(listener);
        }
    }

    /**
     * Add a gesture. When the gesture is matched, any listeners added with
     * {@link #addListener(GestureListener)} will be called.
     * 
     * @param gesture
     *            the gesture to add
     */
    public void addGesture(String gesture) {
        this.gestures.add(gesture);
    }

    /**
     * Removes a gesture previously added with {@link #addGesture(String)}
     * 
     * @param gesture
     *            the gesture to remove
     */
    public void removeGesture(String gesture) {
        this.gestures.remove(gesture);
    }

    /**
     * Get's an iterator that can be used to iterate over the gestures added
     * with {@link #addGesture(String)}
     * 
     * @return the gesture iterator
     */
    public Iterator getGestureIterator() {
        return this.gestures.iterator();
    }

    /**
     * Sets the Levensthein distance limit for matching a gesture: a distance
     * below this will be considered a match.
     * 
     * @param maxDistance
     *            the Levensthein distance limit
     */
    public void setMaxDistance(int maxDistance) {
        this.maxDistance = maxDistance;
    }

    /**
     * Gets the current Levensthein distance limit for matching gestures. A
     * distance below this will be considered a match.
     * 
     * @return the current Levensthein distance limit
     */
    public int getMaxDistance() {
        return this.maxDistance;
    }

    /**
     * Turns the record mode on or off. When record mode is on, listeners will
     * be called even if no gesture is matched.
     * 
     * @param recordMode
     *            recordmode, true=on
     */
    public void setRecordMode(boolean recordMode) {
        this.recording = recordMode;
    }

    /**
     * When record mode is on, listeners will be called even if no gesture is
     * matched.
     * 
     * @return true if record mode is on
     */
    public boolean isRecordMode() {
        return this.recording;
    }

    /**
     * When normalizing is on, gestures will not contain multiple consecutive
     * moves in the same direction. This might be useful if you are not
     * interested in the size of the gesture - note, however, that you can not
     * make different length 'sides' when normalizing, e.g you can not
     * distinguish between a square and a rectangle.
     * 
     * @param normalize
     *            true if the gesture should be normalized.
     */
    public void setNormalizing(boolean normalize) {
        this.normalize = normalize;
    }

    /**
     * Checks whether or not normalizing is on.
     * 
     * @return true if normalizing is on
     */
    public boolean isNormalizing() {
        return this.normalize;
    }

    /**
     * Listener for gesture events.
     * <p>
     * When the user has performed a potential gesture, the listener will be
     * called if 1) a gesture is matched, or 2) record mode is on.
     * </p>
     * <p>
     * When record mode is active, 'matched' will contain the gesture that would
     * have been matched if record mode was off, if such a gesture exists. One
     * can choose to replace such a gesture, or just go ahead and add a new one.
     * Distance will tell exactly how similar the matched gesture is.
     * </p>
     */
    public interface GestureListener {
        public void gestureEvent(String matched, String moved, int distance,
                boolean recordMode);
    }

    // Privates

    private boolean handleEvent(Event event) {
        int type = event.getTypeInt();
        if ((type & Event.TOUCHEVENTS) == 0 && event.getButton() != mouseButton) {
            return true;
        }

        switch (type) {
        case Event.ONMOUSEDOWN:
            // Not working w/ current Vaadin/GWT
            // event.preventDefault();
            startGesture(event.getClientX(), event.getClientY());
            break;
        case Event.ONMOUSEUP:
            stopGesture(event.getClientX(), event.getClientY());
            break;
        case Event.ONMOUSEMOVE:
            gesturing(event.getClientX(), event.getClientY());
            break;

        case Event.ONTOUCHSTART:
            // Not working w/ current Vaadin/GWT
            // event.preventDefault();
            startGesture(event.getChangedTouches().get(0).getClientX(), event
                    .getChangedTouches().get(0).getClientY());
            break;
        case Event.ONTOUCHEND:
        case Event.ONTOUCHCANCEL:
            // TODO cancel should be separate
            stopGesture(event.getChangedTouches().get(0).getClientX(), event
                    .getChangedTouches().get(0).getClientY());
            break;
        case Event.ONTOUCHMOVE:
            gesturing(event.getChangedTouches().get(0).getClientX(), event
                    .getChangedTouches().get(0).getClientY());
            break;
        }

        return true;
    }

    private void startGesture(int x, int y) {
        moving = true;
        lastMove = -1;
        lastX = x;
        lastY = y;
        moved = "";
        lastMoveTime = new Date().getTime();
    }

    private void stopGesture(int x, int y) {
        moving = false;
        addPoint(x, y);
        matchGesture();
    }

    private void gesturing(int x, int y) {
        long now = new Date().getTime();
        if (!moving || now - lastMoveTime < Common.FREQUENCY) {
            return;
        }
        lastMoveTime = now;
        addPoint(x, y);
    }

    private void addPoint(int x, int y) {
        int dx = x - lastX;
        int dy = y - lastY;
        int d2 = dx * dx * dy * dy;
        int min2 = Common.MIN_MOUSEMOVE * Common.MIN_MOUSEMOVE;
        if (d2 > min2) {
            this.addMove(dx, dy);
            lastX = x;
            lastY = y;
        }
    }

    private void addMove(int diffX, int diffY) {
        int sec = (int) Math.round(Math.atan2(diffY, diffX)
                * (Common.DIRECTIONS / 2 / Math.PI));
        if (sec < 0) {
            sec = Common.DIRECTIONS + sec;
        }
        if (!normalize || lastMove != sec) {
            moved += sec;
            lastMove = sec;
        }
    }

    private void matchGesture() {
        if (moved == null || moved.length() == 0) {
            return;
        }
        int currentDistance = Integer.MAX_VALUE;
        String currentGesture = null;
        for (int i = 0; i < gestures.size(); i++) {
            String gesture = (String) gestures.get(i);
            int distance = this.getLevenshteinDistance(gesture, moved);
            if (distance <= maxDistance && distance < currentDistance) {
                currentDistance = distance;
                currentGesture = gesture;
            }
        }

        if (recording || currentGesture != null) {
            fireGestureEvent(currentGesture, moved, currentDistance, recording);
        }
    }

    private void fireGestureEvent(String matched, String moved, int distance,
            boolean recordMode) {
        if (this.listeners != null) {
            for (Iterator it = this.listeners.iterator(); it.hasNext();) {
                GestureListener l = (GestureListener) it.next();
                l.gestureEvent(matched, moved, distance, recordMode);
            }
        }
    }

    /**
     * 
     * Calculates the Levenshtein Distance between two strings. From Jakarta
     * Commons Lang, StringUtils:
     * <p>
     * Chas Emerick has written an implementation in Java, which avoids an
     * OutOfMemoryError which can occur when my Java implementation is used with
     * very large strings.<br>
     * This implementation of the Levenshtein distance algorithm is from <a
     * href="http://www.merriampark.com/ldjava.htm">http://www
     * .merriampark.com/ldjava.htm</a>
     * </p>
     * 
     * @param s
     *            first string
     * @param t
     *            second string
     * @return the Levenshtein Distance between the first and second string
     */
    public static int getLevenshteinDistance(String s, String t) {
        if (s == null || t == null) {
            throw new IllegalArgumentException("Strings must not be null");
        }

        /*
         * The difference between this impl. and the previous is that, rather
         * than creating and retaining a matrix of size s.length()+1 by
         * t.length()+1, we maintain two single-dimensional arrays of length
         * s.length()+1. The first, d, is the 'current working' distance array
         * that maintains the newest distance cost counts as we iterate through
         * the characters of String s. Each time we increment the index of
         * String t we are comparing, d is copied to p, the second int[]. Doing
         * so allows us to retain the previous cost counts as required by the
         * algorithm (taking the minimum of the cost count to the left, up one,
         * and diagonally up and to the left of the current cost count being
         * calculated). (Note that the arrays aren't really copied anymore, just
         * switched...this is clearly much better than cloning an array or doing
         * a System.arraycopy() each time through the outer loop.)
         * 
         * Effectively, the difference between the two implementations is this
         * one does not cause an out of memory condition when calculating the LD
         * over two very large strings.
         */

        int n = s.length(); // length of s
        int m = t.length(); // length of t

        if (n == 0) {
            return m;
        } else if (m == 0) {
            return n;
        }

        if (n > m) {
            // swap the input strings to consume less memory
            String tmp = s;
            s = t;
            t = tmp;
            n = m;
            m = t.length();
        }

        int p[] = new int[n + 1]; // 'previous' cost array, horizontally
        int d[] = new int[n + 1]; // cost array, horizontally
        int _d[]; // placeholder to assist in swapping p and d

        // indexes into strings s and t
        int i; // iterates through s
        int j; // iterates through t

        char t_j; // jth character of t

        int cost; // cost

        for (i = 0; i <= n; i++) {
            p[i] = i;
        }

        for (j = 1; j <= m; j++) {
            t_j = t.charAt(j - 1);
            d[0] = j;

            for (i = 1; i <= n; i++) {
                cost = s.charAt(i - 1) == t_j ? 0 : 1;
                // minimum of cell to the left+1, to the top+1, diagonally left
                // and up +cost
                d[i] = Math.min(Math.min(d[i - 1] + 1, p[i] + 1), p[i - 1]
                        + cost);
            }

            // copy current distance counts to 'previous row' distance counts
            _d = p;
            p = d;
            d = _d;
        }

        // our last action in the above loop was to switch d and p, so p now
        // actually has the most recent cost counts
        return p[n];
    }

    /*
     * IT Mill Toolkit stuff: read UIDL and update accordingly
     * 
     * Don't use this outside IT Mill Toolkit (i.e when using as plain GWT
     * widget);
     */
    public void updateFromUIDL(UIDL uidl, ApplicationConnection client) {
        this.client = client;
        this.pid = uidl.getId();

        if (uidl.hasAttribute(Common.ATTR_MAXDISTANCE)) {
            setMaxDistance(uidl.getIntAttribute(Common.ATTR_MAXDISTANCE));
        }

        if (uidl.hasAttribute(Common.ATTR_RECORD)) {
            setRecordMode(uidl.getBooleanAttribute(Common.ATTR_RECORD));
        }

        if (uidl.hasAttribute(Common.ATTR_NORMALIZE)) {
            setNormalizing(uidl.getBooleanAttribute(Common.ATTR_NORMALIZE));
        }

        this.gestures = new ArrayList();
        for (int i = 0; i < uidl.getChildCount(); i++) {
            UIDL u = uidl.getChildUIDL(i);
            if (u.getTag().equals(Common.GESTURES_TAG)) {
                for (int j = 0; j < u.getChildCount(); j++) {
                    UIDL guidl = u.getChildUIDL(j);
                    String gesture = guidl
                            .getStringAttribute(Common.GESTURE_ATTR_MOVES);
                    addGesture(gesture);
                }
            }
        }

        if (listeners == null) {
            // avoid adding this many times
            addListener(new GestureListener() {
                public void gestureEvent(String matched, String moved,
                        int distance, boolean recording) {
                    VSimpleGesture.this.client.updateVariable(pid,
                            Common.VAR_MATCHED, matched, false);
                    VSimpleGesture.this.client.updateVariable(pid,
                            Common.VAR_MOVED, moved, false);
                    VSimpleGesture.this.client.updateVariable(pid,
                            Common.VAR_DISTANCE, distance, true);
                }
            });
        }
    }
}
